<?php

/**
 * AutoLoader singleton class.
 * Based on h1ppo's AutoLoader: https://github.com/h1ppo/Class-Map-Autoloader-for-PHP
 *
 */

class SJB_Autoloader
{

	protected $rootDirectory;
	protected $classMap = array();
	protected $reloadClassMap;
	protected $fileExt = array(".php" => true, ".php4" => false, ".php5" => false);
	protected static $instance;
	protected $cacheFile = ".classmapcache.php";
	protected $ignore = array();
	protected $rebuiltInThisExecution = false;

	protected $removeRequiresLogFile = "remove_requires.log";
	protected $filesChecked = 0;
	protected $filesChanged = 0;

	/**
	 *
	 * @param string $rootDirectory
	 * @param boolean $reloadClassMap
	 * @param array $fileExt
	 */
	protected function __construct($rootDirectory = null, $reloadClassMap = true, $fileExt = null)
	{
		$this->rootDirectory = ($rootDirectory != null) ? $rootDirectory : dirname(__FILE__);
		if (substr($this->rootDirectory, -1) == '/' || substr($this->rootDirectory, -1) == DIRECTORY_SEPARATOR) {
			$this->rootDirectory = substr($this->rootDirectory, 0, -1);
		}
		$this->reloadClassMap = $reloadClassMap;
		$this->fileExt        = ($fileExt != null && is_array($fileExt)) ? $fileExt : $this->fileExt;

		spl_autoload_register(__CLASS__ . "::autoload");
	}
	
	
	/**
	 * initialises the class map
	 */
	public function init()
	{
		try {
			$this->loadFromCache();
		} catch (Exception $ex) {
			$this->rebuild();
		}
	}

	
	/**
	 * Adds a path to the ignore list
	 * @param string $path
	 * @return AutoLoader
	 */
	public function ignore($path)
	{
		$this->ignore[$path] = true;
		return $this;
	}

	
	/**
	 * Returns the instance of the AutoLoader Singleton or instantiates a new one
	 * @return AutoLoader
	 */
	public static function instance($rootDirectory = null, $reloadClassMap = true, $fileExt = null)
	{
		if (self::$instance == null) {
			self::$instance = new SJB_Autoloader($rootDirectory, $reloadClassMap, $fileExt);
		}
		return self::$instance;
	}
	

	/**
	 * rebuilds the class map
	 */
	public function rebuild()
	{
		// rebuild if we are allowed and we haven't done so already during this execution
		if ($this->reloadClassMap && !$this->rebuiltInThisExecution) { 
			$this->classMap = array();
			$this->mapFilesInDir($this->rootDirectory);
			$this->saveToCache();
			$this->rebuiltInThisExecution = true;
		} else {
			throw new Exception("Unable to rebuild class map");
		}
	}


	public function removeRequires()
	{
		// if second param is true, than 'remove requires' mode is on
		$this->mapFilesInDir($this->rootDirectory, true);

		// if we here - requires removed successfully. Write statistics in log-file
		file_put_contents($this->rootDirectory . DIRECTORY_SEPARATOR . $this->removeRequiresLogFile, "files checked: " . $this->getFilesCheckedNumber() . "\n", FILE_APPEND);
		file_put_contents($this->rootDirectory . DIRECTORY_SEPARATOR . $this->removeRequiresLogFile, "files changed: " . $this->getFilesChangedNumber() . "\n", FILE_APPEND);
	}

	
	/**
	 * @return string
	 */
	public function getCacheLocation()
	{
		// cachemap file in same folder with class
		return dirname(__FILE__) . DIRECTORY_SEPARATOR . $this->cacheFile;
	}

	
	/**
	 *
	 * @param array $classMap
	 */
	public function setClassMap(array $classMap)
	{
		$this->classMap = $classMap;
	}

	
	/**
	 * Loads the class map from the cache file
	 */
	protected function loadFromCache()
	{
		if (file_exists($this->getCacheLocation())) {
			try {
				include $this->getCacheLocation();
				return;
			} catch (Exception $ex) {
				// fall below and throw exception
			}
		}
		throw new Exception("Unable to load cache file " . $this->getCacheLocation());
	}

	
	/**
	 * saves the classMap array to the cacheFile
	 */
	public function saveToCache()
	{
		$contentToWrite  = "<?php\n";
		$contentToWrite .= "/**\n";
		$contentToWrite .= " * Do not edit\n";
		$contentToWrite .= " * Generated by ".__CLASS__."\n";
		$contentToWrite .= " * Generated at ".date("Y-m-d H:i:s")."\n";
		$contentToWrite .= " */\n";
		$contentToWrite .= __CLASS__."::instance()->setClassMap(".var_export($this->classMap, true).");";
		$fp = fopen($this->getCacheLocation(), 'w');
		fwrite($fp, $contentToWrite);
		fclose($fp);
		chmod($this->getCacheLocation(), 0775);
	}

	
	/**
	 * expires the cache
	 */
	public function expireCache()
	{
		try {
			if (file_exists($this->getCacheLocation())) {
				unlink($this->getCacheLocation());
			}
		} catch (Exception $ex) {
			// file didn't exist so it doesn't matter.
		}
	}

	
	/**
	 * Scan a directory for file with an accepted extension and add them to the map.
	 * @param string $dir
	 * @param boolean $removeRequires
	 */
	protected function mapFilesInDir($dir, $removeRequires = false)
	{
		$handle = opendir($dir);
		while (($file = readdir($handle)) !== false) {
			if ($file[0] == ".") {
				continue;
			}
			$ext = ".".pathinfo($dir. DIRECTORY_SEPARATOR . $file , PATHINFO_EXTENSION);
			$filepath = $dir == '.' ? $file : $dir . DIRECTORY_SEPARATOR . $file;
			
			$ignore   = isset($this->ignore[$filepath]) && $this->ignore[$filepath] == true ? true : false;
			$fileExt  = isset($this->fileExt[$ext]) && $this->fileExt[$ext] == true ? true : false;

			if (is_dir($filepath) && !$ignore) {
				$this->mapFilesInDir($filepath, $removeRequires);
			}
			else if (is_file($filepath) && $fileExt && $filepath != $this->getCacheLocation() && !$ignore) {
				if (!$removeRequires) {
					$this->loadClassesFromFile($filepath);
				} else {
					$this->removeRequiresFromFile($filepath);
				}
			}
		}
		closedir($handle);
	}

	
	/**
	 * Scans a file for classes
	 * @todo Add namespace support
	 * @param string $filepath
	 */
	protected function loadClassesFromFile($filepath)
	{
		$sourceCode = file_get_contents($filepath);
		$tokens     = @token_get_all($sourceCode);
		
		$count     = count($tokens);
		$namespace = "";
		for ($i = 2; $i < $count; $i++) {
			
			/*
			// NAMESPACES - SUPPORTS ONLY FOR PHP >= 5.3
			if ($tokens[$i - 2][0] == T_NAMESPACE && $tokens[$i - 1][0] == T_WHITESPACE && $tokens[$i][0] == T_STRING) {
				$namespace = $tokens[$i][1];
				// we've found a namespace now carry on until we hit some whitespace
				$i++;
				while ($tokens[$i][0] != T_WHITESPACE) {
					$namespace .= $tokens[$i][1];
					$i++;
				}
			}
			*/
			if (($tokens[$i - 2][0] == T_CLASS || $tokens[$i - 2][0] == T_INTERFACE) &&
				$tokens[$i - 1][0] == T_WHITESPACE &&
				$tokens[$i][0] == T_STRING) {
					
				$className = $tokens[$i][1];
				if ($namespace != '') {
					$className = $namespace . '\\' . $className;
				}
				$filepathToClass = str_replace($this->rootDirectory . DIRECTORY_SEPARATOR, "", $filepath);
				// replace '\\' in path (default DS on windows system) to '/' (it will more safely to use on all types of OS)
				$this->classMap[strtolower($className)] = str_replace('\\', '/', $filepathToClass);
				$namespace = "";
			}
		}
	}


	/**
	 * This function removes all 'require' and 'require_once' strings from file.
	 *
	 * @param string $filepath
	 */
	protected function removeRequiresFromFile($filepath)
	{
		$this->filesChecked++;

		$sourceCode = file_get_contents($filepath);
		$tokens = @token_get_all($sourceCode);

		$newFileContent = '';

		$waitingSemicolon = false;
		$removeNewLine    = false;
		$fileWasChanged   = false;

		foreach ($tokens as $token) {

			if ($waitingSemicolon) {
				if (is_string($token) && $token == ';') {
					// if we found end of 'require' or 'require_once' instruction - switch flag to remove following newline
					$waitingSemicolon = false;
					$removeNewLine    = true;
				}

			} elseif (is_string($token) && !$waitingSemicolon) {
			   // simple 1-character token
				if ($removeNewLine) {
					// just skip and switch
					$removeNewLine = false;
				} else {
					$newFileContent .= $token;
				}
		   } else {
			   // token array
			   list($id, $text) = $token;

			   switch ($id) {
				   case T_REQUIRE:
				   case T_REQUIRE_ONCE:
				   		$waitingSemicolon = true;
						$fileWasChanged   = true;
						// no action on require
					   break;

				   case T_WHITESPACE:
					   // skip whitespace if skip flag set
					   if (!$removeNewLine) {
						   $newFileContent .= $text;
					   } else {
						   // ok, we skip new line following ';', and switch flag off
						   $removeNewLine = false;
					   }
					   break;

				   default:
					   // anything else -> output "as is"
					   $newFileContent .= $text;
					   break;
			   }
		   }
		}

		// write our new content to file
		if (!file_put_contents($filepath, $newFileContent)) {
			$string = "Unable to save new file content in file " . $filepath . "\nExecution stopped.";
			file_put_contents($this->rootDirectory . DIRECTORY_SEPARATOR . $this->removeRequiresLogFile, $string, FILE_APPEND);
			exit($string);
		} else {
			if ($fileWasChanged === true) {
				$string = "Requires successfully removed from file " . $filepath . "\n";
				$this->filesChanged++;
			} else {
				$string = "No requires found in file " . $filepath . "\n";
			}
			file_put_contents($this->rootDirectory . DIRECTORY_SEPARATOR . $this->removeRequiresLogFile, $string, FILE_APPEND);
		}
	}

	
	/**
	 * Attempts to include the requested class name if it doesn't already exist
	 * and is in our class map
	 * @todo look at PHP5.3 and namespace issues...
	 * @param string $className
	 * @return boolean
	 */
	public function includeClass($className)
	{
		// this used to reassign className but one day php thought that it wouldn't strtolower MySQL_Query_i, it just left it, randomly. way to go PHP
		$lowerClassName = strtolower($className); // yup...
		if (class_exists($lowerClassName)) {
			return true;
		}
		
		if (isset($this->classMap[$lowerClassName])) {
			try {
				include_once $this->rootDirectory . DIRECTORY_SEPARATOR . $this->classMap[$lowerClassName];
				return true;
			}
			catch (Exception $ex) { /*drop below and return false */ }
		}

		return false;
	}

	
	/**
	 * This function is registered with the SPL
	 * @param string $className
	 */
	public static function autoload($className)
	{
		return self::instance()->includeClass($className);
	}


	/**
	 * Returns number of files checked by 'remove requires' operation
	 *
	 * @return int
	 */
	public function getFilesCheckedNumber()
	{
		return $this->filesChecked;
	}

	/**
	 * Returns number of files that changed by 'remove requires' operation
	 *
	 * @return int
	 */
	public function getFilesChangedNumber()
	{
		return $this->filesChanged;
	}
}
